 
/*
 Copyright (c) 2013, 2021, Oracle and/or its affiliates. All rights
 reserved.
 
 This program is free software; you can redistribute it and/or modify
 it under the terms of the GNU General Public License, version 2.0,
 as published by the Free Software Foundation.

 This program is also distributed with certain software (including
 but not limited to OpenSSL) that is licensed under separate terms,
 as designated in a particular file or component or in included license
 documentation.  The authors of MySQL hereby grant you an additional
 permission to link the program and your derivative works with the
 separately licensed software that they have included with MySQL.

 This program is distributed in the hope that it will be useful,
 but WITHOUT ANY WARRANTY; without even the implied warranty of
 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 GNU General Public License, version 2.0, for more details.

 You should have received a copy of the GNU General Public License
 along with this program; if not, write to the Free Software
 Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA
 02110-1301  USA
 */

"use strict";

var udebug       = unified_debug.getLogger("TableMapping.js"),
    path         = require("path"),
    util         = require("util"),
    doc          = require(path.join(mynode.fs.api_doc_dir, "TableMapping"));

/* file scope mapping id used to uniquely identify a mapped domain object */
var mappingId = 0;

/* Code to verify the validity of a TableMapping */

function isString(value) { 
  return (typeof value === 'string' && value !== null);
}

function isNonEmptyString(value) {
  return (isString(value) && value.length > 0);
}

function isBool(value) {
  return (value === true || value === false);
}

function isValidConverterObject(converter) {
  return ((converter === null) || 
            (typeof converter === 'object'
             && typeof converter.toDB === 'function' 
             && typeof converter.fromDB === 'function'));
}

function isValidConstructor(constructor) {
  return (constructor != null && typeof constructor === 'function');
}

function isMeta(value) {
  var i;
  if (Array.isArray(value)) {
    for (i=0; i > value.length; ++i) {
      if (!value[i].isMeta || ! value[i].isMeta()) {
        return false;
      }
    }
  } else {
    return (value.isMeta());
  }
  return true;
}

function Relationship() {
}
Relationship.prototype.relationship = true;
Relationship.prototype.persistent   = true;

function OneToOneMapping() {
}
OneToOneMapping.prototype = new Relationship();

function OneToManyMapping() {
}
OneToManyMapping.prototype = new Relationship();
OneToManyMapping.prototype.toMany = true;

function ManyToOneMapping() {
}
ManyToOneMapping.prototype = new Relationship();
ManyToOneMapping.prototype.manyTo = true;

function ManyToManyMapping() {
}
ManyToManyMapping.prototype = new Relationship();
ManyToManyMapping.prototype.toMany = true;
ManyToManyMapping.prototype.manyTo = true;

var fieldMappingProperties = {
  "fieldName"    : isNonEmptyString,
  "columnName"   : isString,
  "persistent"   : isBool,
  "converter"    : isValidConverterObject,
  "relationship" : isBool,
  "user"         : function() { return true; },
  "meta"         : isMeta
};

var manyToOneMappingProperties = {
  "type"           : "ManyToOne",
  "foreignKey"     : isNonEmptyString,
  "target"         : isValidConstructor,
  "targetField"    : isNonEmptyString,
  "fieldName"      : isNonEmptyString,
  "columnName"     : isString,
  "converter"      : isValidConverterObject,
  "user"           : function() { return true; },
  "ctor"           : ManyToOneMapping
};

var oneToManyMappingProperties = {
  "type"           : "OneToMany",
  "target"         : isValidConstructor,
  "targetField"    : isNonEmptyString,
  "fieldName"      : isNonEmptyString,
  "columnName"     : isString,
  "converter"      : isValidConverterObject,
  "user"           : function() { return true; },
  "ctor"           : OneToManyMapping
};

var manyToManyMappingProperties = {
  "type"           : "ManyToMany",
  "target"         : isValidConstructor,
  "targetField"    : isNonEmptyString,
  "fieldName"      : isNonEmptyString,
  "columnName"     : isString,
  "converter"      : isValidConverterObject,
  "joinTable"      : isNonEmptyString,
  "user"           : function() { return true; },
  "ctor"           : ManyToManyMapping
};

var oneToOneMappingProperties = {
  "type"           : "OneToOne",
  "foreignKey"     : isNonEmptyString,
  "target"         : isValidConstructor,
  "targetField"    : isNonEmptyString,
  "fieldName"      : isNonEmptyString,
  "columnName"     : isString,
  "converter"      : isValidConverterObject,
  "user"           : function() { return true; },
  "ctor"           : OneToOneMapping
};

// These functions return error message, or empty string if valid
function verifyProperty(property, value, verifiers) {
  udebug.log_detail('verifyProperty', property, value);
  var isValid = '', chk;
  if(verifiers[property]) {
    chk = verifiers[property](value);    
    if(chk !== true && chk.length) {
      isValid = 'property ' + property + ' invalid: ' + chk;
    }
    else if(chk === false) {
      isValid = 'property ' + property + ' invalid: ' + JSON.stringify(value);
    }
  }
  else if(typeof value !== 'function') {
    isValid = 'unknown property ' + property +'; ' ;
  }
  return isValid;
}

function isValidMapping(m, verifiers) {
  var property, isValid = '';
  for(property in m) {
    if(m.hasOwnProperty(property)) {
      isValid += verifyProperty(property, m[property], verifiers);
    }
  }
  return isValid;
}    

function isValidFieldMapping(fm, number) {
  var reason = isValidMapping(fm, fieldMappingProperties);
  number = number || '';
  if(reason.length) {
    return "field " + number + " is not a valid FieldMapping: " + reason;
  }
  return '';
}

function isValidFieldMappingArray(fieldMappings) {
  var i, isValid = '';
  if(fieldMappings !== null) {
    for(i = 0; i < fieldMappings.length ; i++) {
      isValid += isValidFieldMapping(fieldMappings[i], i+1);
    }
  }
  return isValid;
}

function isStringOrStringArray(arg) {
  var i;
  if (typeof arg === 'string') return true;
  if (!Array.isArray(arg)) return 'must be a string or string array';
  for (i = 0; i < arg.length; ++i) {
    if (typeof arg[i] !== 'string') return 'must be a string or string array';
  }
  return true;
}


var tableMappingProperties = {
  "error"         : isString,
  "table"         : isNonEmptyString,
  "database"      : isString, 
  "mapAllColumns" : isBool,
  "field"         : isValidFieldMapping,
  "fields"        : isValidFieldMappingArray,
  "user"          : function() { return true; },
  "excludedFieldNames": isStringOrStringArray,
  "mappedFieldNames" : isStringOrStringArray,
  "meta"          : isMeta
};

function isValidTableMapping(tm) {
  var err = isValidMapping(tm, tableMappingProperties);
  if (!err) {
    // make sure there is a valid table
    if (!tm.hasOwnProperty('table')) {
      return '\nRequired property \'table\' is missing.';
    }
  } else {
    return err;
  }
}

function buildMappingFromObject(mapping, literal, verifier) {
  var p, keys, key;
  keys = Object.keys(verifier);
  for(p in keys) {
    key = keys[p];
    if(typeof literal[key] !== 'undefined') {
      mapping[key] = literal[key];
    }
  }
}

/* A canonical TableMapping has a "fields" array,
   though a literal one may have a "field" or "fields" object or array
*/
function makeCanonical(tableMapping) {
  if(tableMapping.field) {            // rename field => fields
    tableMapping.fields = tableMapping.field;
    delete tableMapping.field;
  }

  if(! tableMapping.fields) {
    tableMapping.fields = [];        // create empty fields array if needed
  }                             
  else if(! Array.isArray(tableMapping.fields)) {
    tableMapping.fields = [ tableMapping.fields ];
  }
}


/* TableMapping constructor
   Takes tableName or tableMappingLiteral
*/
function TableMapping(tableNameOrLiteral) {
  var err;
  var i, arg;
  switch(typeof tableNameOrLiteral) {
    case 'object':
      buildMappingFromObject(this, tableNameOrLiteral, tableMappingProperties);
      makeCanonical(this);
      break;

    case 'string':
      var parts = tableNameOrLiteral.split(".");
      if (parts[2] || tableNameOrLiteral.indexOf(' ') !== -1) {
        this.error = 'MappingError: tableName must contain one or two parts: [database.]table';
        this.table = parts[0];
      } else if(parts[0] && parts[1]) {
        this.database = parts[0];
        this.table = parts[1];
      }
      else {
        this.table = parts[0];
      }
      this.fields = [];
      this.mappedFieldNames = [];
      if (arguments.length >1) {
        this.meta = [];
        // look for optional meta following the table name
        for (i = 1; i < arguments.length; i++) {
          arg = arguments[i];
          if (arg && arg.isMeta && arg.isMeta()) {
            this.meta.push(arg);
          } else {
            this.error += 'MappingError: valid arguments are meta; invalid argument ' + i + ': (' + typeof arg + ') ' + arg;
          }
        }
      }
      break;
    
    default: 
      this.error = "MappingError: string tableName or literal tableMapping is a required parameter.";
  }
  err = isValidTableMapping(this);
   if (err) {
    this.error += err;
  }
}
/* Get prototype from documentation
*/
TableMapping.prototype = doc.TableMapping;


/* FieldMapping constructor
 * This is exported & used by DBTableHandler, but not by the public.
 */
function FieldMapping(fieldName) {
  this.fieldName  = fieldName;
  this.columnName = fieldName; 
  this.relationship = false;
}
FieldMapping.prototype = doc.FieldMapping;


/* mapField(fieldName, [columnName], [converter], [persistent])
   mapField(literalFieldMapping)
   IMMEDIATE

   Create or replace FieldMapping for fieldName
*/
TableMapping.prototype.mapField = function() {
  var i, args, arg, fieldName, fieldMapping;
  args = arguments;  

  function getFieldMapping(tableMapping, fieldName) {
    var fm, i;
    for(i = 0 ; i < tableMapping.fields.length ; i++) {
      fm = tableMapping.fields[i];
      if(fm.fieldName === fieldName) {
        return fm;
      }
    }
    fm = new FieldMapping(fieldName);
    tableMapping.fields.push(fm);
    return fm;
  }

  /* mapField() starts here */
  arg = args[0];
  if(typeof arg === 'string') {
    fieldName = arg;
    fieldMapping = getFieldMapping(this, fieldName);
    for(i = 1; i < args.length ; i++) {
      arg = args[i];
      switch(typeof arg) {
        case 'string':
          fieldMapping.columnName = arg;
          break;
        case 'boolean':
          fieldMapping.persistent = arg;
          break;
        case 'object':
          // argument is a meta or converter
          if (arg && arg.isMeta && arg.isMeta()) {
            fieldMapping.meta = arg;
          } else {
            fieldMapping.converter = arg;
          }
          break;
        default:
          this.error += "mapField(): Invalid argument " + arg;
      }
    }
  }
  else if(typeof args[0] === 'object') {
    fieldName = args[0].fieldName;
    fieldMapping = getFieldMapping(this, fieldName);
    buildMappingFromObject(fieldMapping, args[0], fieldMappingProperties);
  }
  else {
    this.error +="\nmapField() expects a literal FieldMapping or valid arguments list";
  }

  /* Validate the candidate mapping */
  this.error  += isValidFieldMapping(fieldMapping);
  this.mappedFieldNames.push(fieldName);
  return this;
};

function createRelationshipFieldFromLiteral(relationshipProperties, tableMapping, literal) {
  var relationship = new relationshipProperties.ctor();
  relationship.error = '';
  var fieldValidator, value, valid;
  var errorMessage = "";
  // iterate the literal and set properties
  var literalField;
  for (literalField in literal) {
    if (literal.hasOwnProperty(literalField)) {
      // validate each field in the literal
      udebug.log_detail('createRelationshipFieldFromLiteral validating', relationshipProperties.type, literalField, 
          literal[literalField]);
      fieldValidator = relationshipProperties[literalField];
      if (!fieldValidator) {
        errorMessage += "\nMappingError: invalid literal field: " + literalField + "\n";
      } else {
        value = literal[literalField];
        valid = fieldValidator(value);
        udebug.log_detail('createRelationshipFieldFromLiteral fieldValidator for', literalField, "is", valid);
        if (valid) {
          relationship[literalField] = value;
        } else {
          errorMessage += "\nMappingError: invalid value for literal field: " + literalField + "\n";
        }
      }
    }
  }
  if (!relationship.fieldName) {
    errorMessage += "\nMappingError: fieldName is a required field for relationship mapping";
  }
  if (!relationship.targetField && !relationship.foreignKey && !relationship.joinTable) {
    errorMessage += "\nMappingError: targetField, foreignKey, or joinTable is a required field for relationship mapping";
  }
  if (!relationship.target) {
    errorMessage += '\nMappingError: target is a required field for relationship mapping';
  }
  if (errorMessage) {
    tableMapping.error += errorMessage;
  }
  return relationship;
}

/* mapOneToOne(literalFieldMapping)
 * IMMEDIATE
 */
TableMapping.prototype.mapOneToOne = function(literalMapping) {
  var mapping;
  if (typeof literalMapping === 'object') {
    mapping = createRelationshipFieldFromLiteral(oneToOneMappingProperties, this, literalMapping);
    this.fields.push(mapping);
  } else {
    this.error += '\nMappingError: mapOneToOne supports only literal field mapping';
  }
  return this;
};

/* mapManyToOne(literalFieldMapping)
 * IMMEDIATE
 */
TableMapping.prototype.mapManyToOne = function(literalMapping) {
  var mapping;
  if (typeof literalMapping === 'object') {
    mapping = createRelationshipFieldFromLiteral(manyToOneMappingProperties, this, literalMapping);
    this.fields.push(mapping);
  } else {
    this.error += '\nMappingError: mapManyToOne supports only literal field mapping';
  }
  return this;
};

/* mapOneToMany(literalFieldMapping)
 * IMMEDIATE
 */
TableMapping.prototype.mapOneToMany = function(literalMapping) {
  var mapping;
  if (typeof literalMapping === 'object') {
    mapping = createRelationshipFieldFromLiteral(oneToManyMappingProperties, this, literalMapping);
    this.fields.push(mapping);
  } else {
    this.error += '\nMappingError: mapManyToOne supports only literal field mapping';
  }
  return this;
};

/* mapManyToMany(literalFieldMapping)
 * IMMEDIATE
 */
TableMapping.prototype.mapManyToMany = function(literalMapping) {
  var mapping;
  if (typeof literalMapping === 'object') {
    mapping = createRelationshipFieldFromLiteral(manyToManyMappingProperties, this, literalMapping);
    this.fields.push(mapping);
  } else {
    this.error += '\nMappingError: mapManyToOne supports only literal field mapping';
  }
  return this;
};

/** excludeFields(fieldNames)
 * Exclude the named field(s) from being persisted as part of sparse field handling.
 */
TableMapping.prototype.excludeFields = function() {
  var i, j, fieldName;
  if (!this.excludedFieldNames) this.excludedFieldNames = [];
  for (i = 0; i < arguments.length; ++i) {
    var fieldNames = arguments[i];
    if (typeof fieldNames === 'string') {
      this.excludedFieldNames.push(fieldNames);
    } else if (Array.isArray(fieldNames)) {
      for (j = 0; j < fieldNames.length; ++j) {
        fieldName = fieldNames[j];
        if (typeof fieldName === 'string') {
          this.excludedFieldNames.push(fieldName);
        } else {
          this.error += '\nMappingError: excludeFields argument must be a field name or an array or list of field names: \"' +
              fieldName + '\"';
        }
      }
    } else {
      this.error += '\nMappingError: excludeFields argument must be a field name or an array or list of field names: \"' +
          fieldNames + '\"';
    }
  }
};


/* mapSparseFields(columnName, fieldNames, converter)
 * columnName: required
 * fieldNames: optional string or array of strings
 * converter: optional converter function default Converters/JSONSparseFieldsConverter
 */
TableMapping.prototype.mapSparseFields = function() {
  var i, j, args, arg, columnName, fieldMapping, sparseFieldNames = [];
  args = arguments;  
    
  if(typeof args[0] === 'string') {
    columnName = args[0];
    fieldMapping = new FieldMapping(columnName);
    fieldMapping.tableMapping = this;
    for(i = 1; i < args.length ; i++) {
      arg = args[i];
      switch(typeof arg) {
        case 'string':
          sparseFieldNames.push(arg);
          break;
        case 'object':
          if (Array.isArray(arg)) {
            // verify array of field names
            for (j = 0; j < arg.length; ++j) {
              if (typeof arg[j] !== 'string') {
                this.error += "\nmapSparseFields Illegal argument; element " + j + 
                    " is not a string: \"" + util.inspect(arg[j]) + "\"";
              } else {
                sparseFieldNames.push(arg[j]);
              }
            }
          } else {
            // argument is a meta or converter
            if (arg && arg.isMeta && arg.isMeta()) {
              fieldMapping.meta = arg;
            } else {
              // validate converter
              if (isValidConverterObject(arg)) {
                fieldMapping.converter = arg;
              } else {
                this.error += "\nmapSparseFields Argument is an object " +
                    "that is not a meta, an array of field names, or a converter object: \"" + util.inspect(arg) + "\"";
              }
            }
          }
          break;
        default:
          this.error += "\nmapSparseFields: Argument must be a field name, a meta, an array of field names, or a converter object: \"" + 
            util.inspect(arg) + "\"";
      }
    }
    if (!fieldMapping.converter) {
      // default sparse fields converter
      fieldMapping.converter = mynode.converters.JSONSparseConverter;
    }
    if (sparseFieldNames.length !== 0) {
      fieldMapping.sparseFieldNames = sparseFieldNames;
    }
    fieldMapping.sparseFieldMapping = true;
    this.fields.push(fieldMapping);
  }
  else {
    this.error +="\nmapSparseFields() requires a valid arguments list with column name as the first argument";
  }
  return this;

};


/* applyToClass(constructor) 
   IMMEDIATE
*/
TableMapping.prototype.applyToClass = function(ctor) {
  if (typeof ctor === 'function') {
    ctor.prototype.mynode = {};
    ctor.prototype.mynode.mapping = this;
    ctor.prototype.mynode.constructor = ctor;
    ctor.prototype.mynode.mappingId = ++mappingId;
  } else {
    this.error += '\nMappingError: applyToClass() parameter must be constructor';
  }
  return ctor;
};


/* Public exports of this module: */
exports.TableMapping = TableMapping;
exports.FieldMapping = FieldMapping;
exports.isValidConverterObject = isValidConverterObject;
